docz-cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -312,6 +312,301 @@ var DocSyncClient = class {
312
312
  }
313
313
  };
314
314
 
315
+ // src/collab/text.ts
316
+ import { createHash } from "crypto";
317
+ function collabHash(content) {
318
+ return `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`;
319
+ }
320
+ var CollabConflictError = class extends Error {
321
+ constructor(currentHash, baseHash) {
322
+ super(
323
+ `collaborative document changed since base_collab_hash (current: ${currentHash}, base: ${baseHash})`
324
+ );
325
+ this.currentHash = currentHash;
326
+ this.baseHash = baseHash;
327
+ this.name = "CollabConflictError";
328
+ }
329
+ };
330
+ var CollabBaseHashRequiredError = class extends Error {
331
+ constructor(currentHash) {
332
+ super(
333
+ `base_collab_hash is required unless force is explicitly enabled (current: ${currentHash})`
334
+ );
335
+ this.currentHash = currentHash;
336
+ this.name = "CollabBaseHashRequiredError";
337
+ }
338
+ };
339
+ function getYText(doc) {
340
+ return doc.getText("content");
341
+ }
342
+ function readText(doc) {
343
+ return getYText(doc).toString();
344
+ }
345
+ function replaceText(doc, nextContent, opts = {}) {
346
+ const ytext = getYText(doc);
347
+ const previous = ytext.toString();
348
+ const previousHash = collabHash(previous);
349
+ const baseHash = opts.baseHash;
350
+ if (!opts.force) {
351
+ if (!baseHash) {
352
+ throw new CollabBaseHashRequiredError(previousHash);
353
+ }
354
+ if (previousHash !== baseHash) {
355
+ throw new CollabConflictError(previousHash, baseHash);
356
+ }
357
+ }
358
+ doc.transact(() => {
359
+ ytext.delete(0, ytext.length);
360
+ if (nextContent) ytext.insert(0, nextContent);
361
+ }, opts.origin ?? "docz-cli");
362
+ return { previousHash, hash: collabHash(nextContent) };
363
+ }
364
+
365
+ // src/collab/types.ts
366
+ var CollabUnknownError = class extends Error {
367
+ constructor(message) {
368
+ super(message);
369
+ this.name = "CollabUnknownError";
370
+ }
371
+ };
372
+ var CollabPublishError = class extends Error {
373
+ constructor(message, code) {
374
+ super(message);
375
+ this.code = code;
376
+ this.name = "CollabPublishError";
377
+ }
378
+ };
379
+
380
+ // src/collab/room.ts
381
+ import { randomUUID } from "crypto";
382
+ import {
383
+ HocuspocusProvider,
384
+ HocuspocusProviderWebsocket
385
+ } from "@hocuspocus/provider";
386
+ import WebSocket from "ws";
387
+ import * as Y from "yjs";
388
+
389
+ // src/collab/roomName.ts
390
+ function decodePath(path) {
391
+ try {
392
+ return decodeURIComponent(path);
393
+ } catch {
394
+ return path;
395
+ }
396
+ }
397
+ function normalizeCollabFilePath(path) {
398
+ const decoded = decodePath(path.trim()).replace(/\\/g, "/");
399
+ const parts = [];
400
+ for (const part of decoded.split("/")) {
401
+ if (!part || part === ".") continue;
402
+ if (part === "..") throw new Error("invalid collab file path");
403
+ parts.push(part);
404
+ }
405
+ return parts.join("/");
406
+ }
407
+ function buildCollabDocumentName(spaceId, filePath) {
408
+ const sid = spaceId.trim();
409
+ const normalizedPath = normalizeCollabFilePath(filePath);
410
+ if (!sid || !normalizedPath)
411
+ throw new Error("space id and file path are required");
412
+ return `${sid}:${normalizedPath}`;
413
+ }
414
+
415
+ // src/collab/room.ts
416
+ function wsUrl(baseUrl) {
417
+ const u = new URL(baseUrl);
418
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
419
+ u.pathname = "/collab";
420
+ u.search = "";
421
+ u.hash = "";
422
+ return u.toString();
423
+ }
424
+ function withTimeout(promise, ms, label) {
425
+ let timer;
426
+ const timeout = new Promise((_, reject) => {
427
+ timer = setTimeout(
428
+ () => reject(new CollabUnknownError(`${label} timed out after ${ms}ms`)),
429
+ ms
430
+ );
431
+ });
432
+ return Promise.race([promise, timeout]).finally(() => {
433
+ if (timer) clearTimeout(timer);
434
+ });
435
+ }
436
+ var CollabRoomClient = class {
437
+ doc = new Y.Doc();
438
+ provider = null;
439
+ websocketProvider = null;
440
+ openOptions = null;
441
+ pending = /* @__PURE__ */ new Map();
442
+ connected = false;
443
+ synced = false;
444
+ readOnly = false;
445
+ async open(options) {
446
+ const openOptions = {
447
+ ...options,
448
+ path: normalizeCollabFilePath(options.path),
449
+ timeoutMs: options.timeoutMs ?? 3e4
450
+ };
451
+ this.openOptions = openOptions;
452
+ const opened = new Promise((resolve, reject) => {
453
+ const websocketProvider = new HocuspocusProviderWebsocket({
454
+ url: wsUrl(openOptions.baseUrl),
455
+ WebSocketPolyfill: WebSocket,
456
+ parameters: {
457
+ space_id: openOptions.spaceId,
458
+ file_path: openOptions.path,
459
+ client: openOptions.client,
460
+ client_version: openOptions.clientVersion
461
+ }
462
+ });
463
+ this.websocketProvider = websocketProvider;
464
+ const provider = new HocuspocusProvider({
465
+ websocketProvider,
466
+ name: buildCollabDocumentName(openOptions.spaceId, openOptions.path),
467
+ document: this.doc,
468
+ token: () => openOptions.token,
469
+ onAuthenticated: () => {
470
+ },
471
+ onAuthenticationFailed: (data) => {
472
+ reject(
473
+ new CollabPublishError(
474
+ `collab authentication failed: ${data.reason}`,
475
+ "auth_failed"
476
+ )
477
+ );
478
+ },
479
+ onStatus: ({ status }) => {
480
+ this.connected = status === "connected";
481
+ },
482
+ onSynced: ({ state }) => {
483
+ this.synced = state;
484
+ if (state) resolve();
485
+ },
486
+ onStateless: ({ payload }) => {
487
+ this.handleStateless(payload);
488
+ },
489
+ onDisconnect: () => {
490
+ this.connected = false;
491
+ }
492
+ });
493
+ this.provider = provider;
494
+ });
495
+ await withTimeout(opened, openOptions.timeoutMs, "collab open");
496
+ return this.read();
497
+ }
498
+ read() {
499
+ if (!this.openOptions) throw new Error("collab room is not open");
500
+ const content = readText(this.doc);
501
+ return {
502
+ spaceId: this.openOptions.spaceId,
503
+ path: this.openOptions.path,
504
+ content,
505
+ collabHash: collabHash(content),
506
+ connected: this.connected && this.synced,
507
+ readOnly: this.readOnly
508
+ };
509
+ }
510
+ write(content, opts = {}) {
511
+ if (!this.openOptions) throw new Error("collab room is not open");
512
+ if (this.readOnly)
513
+ throw new CollabPublishError("collab room is read-only", "read_only");
514
+ const result = replaceText(this.doc, content, {
515
+ baseHash: opts.baseHash,
516
+ force: opts.force,
517
+ origin: this.openOptions.client
518
+ });
519
+ return {
520
+ spaceId: this.openOptions.spaceId,
521
+ path: this.openOptions.path,
522
+ previousHash: result.previousHash,
523
+ collabHash: result.hash
524
+ };
525
+ }
526
+ async publish(timeoutMs) {
527
+ if (!this.provider || !this.openOptions)
528
+ throw new Error("collab room is not open");
529
+ const reqId = randomUUID();
530
+ const ms = timeoutMs ?? this.openOptions.timeoutMs ?? 3e4;
531
+ const result = new Promise((resolve, reject) => {
532
+ const timer = setTimeout(() => {
533
+ this.pending.delete(reqId);
534
+ reject(
535
+ new CollabUnknownError(`collab publish ack timed out after ${ms}ms`)
536
+ );
537
+ }, ms);
538
+ this.pending.set(reqId, { resolve, reject, timer });
539
+ });
540
+ this.provider.sendStateless(
541
+ JSON.stringify({
542
+ type: "publish",
543
+ reqId,
544
+ client: this.openOptions.client,
545
+ client_version: this.openOptions.clientVersion
546
+ })
547
+ );
548
+ return result;
549
+ }
550
+ close() {
551
+ for (const [reqId, pending] of this.pending) {
552
+ clearTimeout(pending.timer);
553
+ pending.reject(
554
+ new CollabUnknownError("collab room closed before publish ack")
555
+ );
556
+ this.pending.delete(reqId);
557
+ }
558
+ this.provider?.destroy();
559
+ this.websocketProvider?.destroy();
560
+ this.provider = null;
561
+ this.websocketProvider = null;
562
+ this.doc.destroy();
563
+ }
564
+ handleStateless(payload) {
565
+ let msg;
566
+ try {
567
+ msg = JSON.parse(payload);
568
+ } catch {
569
+ return;
570
+ }
571
+ if (!msg.reqId) return;
572
+ const pending = this.pending.get(msg.reqId);
573
+ if (!pending) return;
574
+ this.pending.delete(msg.reqId);
575
+ clearTimeout(pending.timer);
576
+ if (msg.type === "publish_ack" || msg.type === "recreate_after_delete_ack") {
577
+ if (!this.openOptions) {
578
+ pending.reject(
579
+ new CollabUnknownError("collab room closed before publish ack")
580
+ );
581
+ return;
582
+ }
583
+ pending.resolve({
584
+ spaceId: this.openOptions.spaceId,
585
+ path: this.openOptions.path,
586
+ ref: msg.ref || "",
587
+ contentRef: msg.content_ref || "",
588
+ externalBackup: msg.external_backup || ""
589
+ });
590
+ return;
591
+ }
592
+ pending.reject(
593
+ new CollabPublishError(
594
+ `collab publish failed${msg.code ? `: ${msg.code}` : ""}`,
595
+ msg.code
596
+ )
597
+ );
598
+ }
599
+ };
600
+ async function withCollabRoom(options, fn) {
601
+ const room = new CollabRoomClient();
602
+ try {
603
+ await room.open(options);
604
+ return await fn(room);
605
+ } finally {
606
+ room.close();
607
+ }
608
+ }
609
+
315
610
  // src/config.ts
316
611
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
317
612
  import { homedir } from "os";
@@ -347,6 +642,96 @@ function getConfigPath() {
347
642
  // src/commands.ts
348
643
  import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
349
644
  import { basename } from "path";
645
+
646
+ // src/collab/bridge.ts
647
+ import readline from "readline";
648
+ function send(value) {
649
+ process.stdout.write(`${JSON.stringify(value)}
650
+ `);
651
+ }
652
+ async function startCollabBridge(openRoom) {
653
+ let room = null;
654
+ let cleanupObserver = null;
655
+ const rl = readline.createInterface({
656
+ input: process.stdin,
657
+ crlfDelay: Number.POSITIVE_INFINITY
658
+ });
659
+ function attachObserver(nextRoom) {
660
+ cleanupObserver?.();
661
+ const ytext = nextRoom.doc.getText("content");
662
+ const observer = () => {
663
+ const content = ytext.toString();
664
+ send({ event: "document_change", content, hash: collabHash(content) });
665
+ };
666
+ ytext.observe(observer);
667
+ cleanupObserver = () => ytext.unobserve(observer);
668
+ }
669
+ function closeRoom() {
670
+ cleanupObserver?.();
671
+ cleanupObserver = null;
672
+ room?.close();
673
+ room = null;
674
+ }
675
+ for await (const line of rl) {
676
+ if (!line.trim()) continue;
677
+ let req;
678
+ try {
679
+ req = JSON.parse(line);
680
+ } catch (err) {
681
+ send({ event: "error", code: "bad_json", message: String(err) });
682
+ continue;
683
+ }
684
+ try {
685
+ if (req.method === "open") {
686
+ const target = String(req.params?.target ?? "");
687
+ closeRoom();
688
+ room = await openRoom(target);
689
+ attachObserver(room);
690
+ const current = room.read();
691
+ send({
692
+ id: req.id,
693
+ result: {
694
+ content: current.content,
695
+ hash: current.collabHash,
696
+ read_only: current.readOnly
697
+ }
698
+ });
699
+ send({
700
+ event: "opened",
701
+ content: current.content,
702
+ hash: current.collabHash
703
+ });
704
+ } else if (req.method === "local_change") {
705
+ if (!room) throw new Error("room is not open");
706
+ const content = String(req.params?.content ?? "");
707
+ const baseHash = req.params?.base_hash ? String(req.params.base_hash) : void 0;
708
+ const result = room.write(content, {
709
+ baseHash,
710
+ force: req.params?.force === true
711
+ });
712
+ send({ id: req.id, result: { hash: result.collabHash } });
713
+ } else if (req.method === "publish") {
714
+ if (!room) throw new Error("room is not open");
715
+ send({ id: req.id, result: await room.publish() });
716
+ } else if (req.method === "status") {
717
+ send({ id: req.id, result: room ? room.read() : { connected: false } });
718
+ } else if (req.method === "close") {
719
+ closeRoom();
720
+ send({ id: req.id, result: { ok: true } });
721
+ } else {
722
+ throw new Error(`unknown method: ${req.method}`);
723
+ }
724
+ } catch (err) {
725
+ send({
726
+ id: req.id,
727
+ error: err instanceof Error ? err.message : String(err)
728
+ });
729
+ }
730
+ }
731
+ closeRoom();
732
+ }
733
+
734
+ // src/commands.ts
350
735
  function getClient() {
351
736
  const token = getToken();
352
737
  if (!token) {
@@ -357,6 +742,32 @@ function getClient() {
357
742
  }
358
743
  return new DocSyncClient(getBaseUrl(), token);
359
744
  }
745
+ function getRequiredToken() {
746
+ const token = getToken();
747
+ if (!token) {
748
+ console.error(
749
+ "Error: No token configured.\nRun `docz login` or set DOCSYNC_API_TOKEN environment variable."
750
+ );
751
+ process.exit(1);
752
+ }
753
+ return token;
754
+ }
755
+ async function buildCollabOpenOptions(target, opts = {}) {
756
+ const api = getClient();
757
+ const { spaceId, path } = await resolveTarget(api, [target]);
758
+ if (!path) {
759
+ throw new Error("file path is required for collaborative editing");
760
+ }
761
+ return {
762
+ baseUrl: getBaseUrl(),
763
+ token: getRequiredToken(),
764
+ spaceId,
765
+ path,
766
+ client: opts.client ?? "docz-cli",
767
+ clientVersion: opts.clientVersion ?? "0.10.0",
768
+ timeoutMs: opts.timeout
769
+ };
770
+ }
360
771
  function parseTarget(args) {
361
772
  if (args.length === 0) {
362
773
  console.error(
@@ -887,6 +1298,124 @@ function registerCommands(program) {
887
1298
  const ref = await client.getFileRef(spaceId, path);
888
1299
  console.log(ref.url);
889
1300
  });
1301
+ const collab = program.command("collab").description("Collaborative editing via Docz realtime rooms");
1302
+ collab.command("cat").description(
1303
+ "Read collaborative document \u2014 docz collab cat <space>:<path> or <url>"
1304
+ ).argument("<target>", "space:path or short URL").option("--raw", "Output raw content only").option("--timeout <ms>", "Open timeout in milliseconds", Number).action(
1305
+ async (target, opts) => {
1306
+ const open = await buildCollabOpenOptions(target, {
1307
+ timeout: opts.timeout
1308
+ });
1309
+ await withCollabRoom(open, async (room) => {
1310
+ const result = room.read();
1311
+ if (!opts.raw) {
1312
+ console.error(`collab_hash: ${result.collabHash}`);
1313
+ console.error(`read_only: ${result.readOnly ? "true" : "false"}`);
1314
+ console.error("---");
1315
+ }
1316
+ process.stdout.write(result.content);
1317
+ });
1318
+ }
1319
+ );
1320
+ collab.command("write").description(
1321
+ "Write collaborative document \u2014 docz collab write <space>:<path> <content>"
1322
+ ).argument("<target>", "space:path or short URL").argument("<content>", "File content (or - for stdin)").option("--base-collab-hash <hash>", "Hash returned by docz collab cat").option("--force", "Skip collaborative hash conflict detection").option("--no-publish", "Only update realtime room, do not flush to repo").option("--timeout <ms>", "Open/publish timeout in milliseconds", Number).action(
1323
+ async (target, content, opts) => {
1324
+ const open = await buildCollabOpenOptions(target, {
1325
+ timeout: opts.timeout
1326
+ });
1327
+ const body = content === "-" ? await readStdin() : content;
1328
+ if (Buffer.byteLength(body, "utf-8") > MAX_SAVE_SIZE) {
1329
+ console.error(
1330
+ "Error: content exceeds 2MB limit. Use `docz upload` for large files."
1331
+ );
1332
+ process.exit(1);
1333
+ }
1334
+ try {
1335
+ await withCollabRoom(open, async (room) => {
1336
+ const write = room.write(body, {
1337
+ baseHash: opts.baseCollabHash,
1338
+ force: opts.force
1339
+ });
1340
+ if (opts.publish === false) {
1341
+ console.log(
1342
+ `Updated collaborative room (collab_hash: ${write.collabHash})`
1343
+ );
1344
+ return;
1345
+ }
1346
+ const published = await room.publish(opts.timeout);
1347
+ console.log(
1348
+ `Published: ${published.path} (ref: ${published.ref}, collab_hash: ${write.collabHash})`
1349
+ );
1350
+ if (published.externalBackup) {
1351
+ console.log(`External backup: ${published.externalBackup}`);
1352
+ }
1353
+ });
1354
+ } catch (err) {
1355
+ if (err instanceof CollabConflictError) {
1356
+ console.error(
1357
+ `Error: collaborative document changed. Re-read and retry. current=${err.currentHash} base=${err.baseHash}`
1358
+ );
1359
+ process.exit(1);
1360
+ }
1361
+ if (err instanceof CollabBaseHashRequiredError) {
1362
+ console.error(
1363
+ `Error: --base-collab-hash is required unless --force is set. Re-read first to get the latest hash. current=${err.currentHash}`
1364
+ );
1365
+ process.exit(1);
1366
+ }
1367
+ if (err instanceof CollabUnknownError) {
1368
+ console.error(
1369
+ `Unknown state: ${err.message}. The server may have processed the publish; please re-read before retrying.`
1370
+ );
1371
+ process.exit(75);
1372
+ }
1373
+ throw err;
1374
+ }
1375
+ }
1376
+ );
1377
+ collab.command("publish").description(
1378
+ "Flush collaborative document to repo \u2014 docz collab publish <space>:<path>"
1379
+ ).argument("<target>", "space:path or short URL").option("--timeout <ms>", "Open/publish timeout in milliseconds", Number).action(async (target, opts) => {
1380
+ const open = await buildCollabOpenOptions(target, {
1381
+ timeout: opts.timeout
1382
+ });
1383
+ try {
1384
+ await withCollabRoom(open, async (room) => {
1385
+ const result = await room.publish(opts.timeout);
1386
+ console.log(`Published: ${result.path} (ref: ${result.ref})`);
1387
+ if (result.externalBackup) {
1388
+ console.log(`External backup: ${result.externalBackup}`);
1389
+ }
1390
+ });
1391
+ } catch (err) {
1392
+ if (err instanceof CollabUnknownError) {
1393
+ console.error(
1394
+ `Unknown state: ${err.message}. The server may have processed the publish; please re-read before retrying.`
1395
+ );
1396
+ process.exit(75);
1397
+ }
1398
+ throw err;
1399
+ }
1400
+ });
1401
+ collab.command("bridge").description("Start local JSONL bridge for terminal editors").option("--client <name>", "Client name sent to Docz", "docz.nvim").option(
1402
+ "--client-version <version>",
1403
+ "Client version sent to Docz",
1404
+ "0.10.0"
1405
+ ).option("--timeout <ms>", "Open/publish timeout in milliseconds", Number).action(
1406
+ async (opts) => {
1407
+ await startCollabBridge(async (target) => {
1408
+ const open = await buildCollabOpenOptions(target, {
1409
+ client: opts.client,
1410
+ clientVersion: opts.clientVersion,
1411
+ timeout: opts.timeout
1412
+ });
1413
+ const room = new CollabRoomClient();
1414
+ await room.open(open);
1415
+ return room;
1416
+ });
1417
+ }
1418
+ );
890
1419
  program.command("diff").description(
891
1420
  "Show changes \u2014 docz diff <space>[:<path>] <commit> [<from>] or <url> <commit> [<from>]"
892
1421
  ).argument("<target>", "space or space:path or short URL").argument("<to>", "Commit hash").argument("[from]", "From commit hash (default: to^)").action(async (target, to, from) => {
@@ -915,6 +1444,10 @@ function registerCommands(program) {
915
1444
  export {
916
1445
  ConflictError,
917
1446
  DocSyncClient,
1447
+ CollabConflictError,
1448
+ CollabBaseHashRequiredError,
1449
+ CollabUnknownError,
1450
+ withCollabRoom,
918
1451
  getBaseUrl,
919
1452
  getToken,
920
1453
  parseExpires,
@@ -922,4 +1455,4 @@ export {
922
1455
  markdownImageRef,
923
1456
  registerCommands
924
1457
  };
925
- //# sourceMappingURL=chunk-K3JRXIEV.js.map
1458
+ //# sourceMappingURL=chunk-26LVQHYL.js.map